iT邦幫忙

2024 iThome 鐵人賽

DAY 4
0

在上一篇文章中,我們介紹了程式的執行過程,提到 _start() 會呼叫 __libc_start_main() 函數,而 __libc_start_main() 會依序執行 init()main()fini()。然而,這些函數究竟是如何被呼叫並執行的?

本篇文章將說明 Stack 是如何幫助函數呼叫執行,並說明 main() 在執行過程中,Stack 起到了什麼作用。

本篇文章的架構如下:

  • 函數呼叫
  • Stack 簡介
  • 程式執行流程:函數呼叫與 Stack 機制
    • 呼叫 main() -Stack 用於函數呼叫
    • 執行 main() - Stack 用於儲存函數資料
    • 結束 main() 執行

函數呼叫

由於 main() 是一個函數,因此 __libc_start_main() 想執行它的時候,必須要呼叫它。這時我們需要考慮到三個問題:

  1. 如何把參數傳遞給 main() 函數?
  2. 如何告訴 CPU 現在要跳轉到 main() 函數?
  3. main() 函數執行完後,如何讓 CPU 知道從哪裡繼續執行,即如何跳回到 __libc_start_main() 呼叫 main() 的下一行位置(後續將稱呼為 Return Address)?

解決方法很簡單,就是將參數的值、main() 的位址以及 Return Address 儲存起來。至於存在哪裡、以什麼順序存會依據不同的 CPU 架構有所規範,這個規範稱為呼叫慣例(Calling Convention)。CPU 會依據呼叫慣例,依指定的順序去指定的位置存取這些資料,從而解決上述三個問題。

x86-64 架構下的呼叫慣例如下:

  1. 函數的參數會依序存放在 rdirsirdxrcxr8r9 等暫存器(Registry)中,如果參數超過6個,從第7個參數開始會存放在 Stack 中,回傳值則存放在 rax 暫存器裡
  2. 定義 call 指令需要將 Return Address 推入 Stack 中,接著跳轉到目標函數位址
  3. 定義 ret 指令需要將 Return Address 從 Stack 中彈出

可以看出,呼叫慣例中所有與位址相關的存取操作都依賴於 Stack。在進入具體範例之前,我們先來簡單介紹一下 Stack 的概念。

補充:暫存器(Register)是一種 CPU 的內部元件,用來暫存運算過程中的值和指令。


Stack 簡介

Stack 是一種資料結構,類似於品客洋芋片的罐子。當你往 Stack 裡添加資料時(放洋芋片),它們會依序疊在前一個資料之上,這個動作稱為推入(Push);當需要取出資料時(拿洋芋片),最上面的資料會先被彈出,這個動作稱為彈出(Pop)。可以發現越晚放進去的會越先被拿出,因此它是一種後進先出(LIFO,Last In First Out)的資料結構。

可以注意到上一章說明程式在記憶體的佈局時,Stack 的箭頭是往下,代表它是往下(低位址)生長,可以想像成倒放的洋芋片罐。
image


程式執行流程:函數呼叫與 Stack 機制

接下來,我們將透過組合語言來觀察函數呼叫的實際流程,並解釋 Stack 如何在呼叫、執行函數及函數結束時發揮作用。為了讓讀者能更具體地理解這些過程,我們會使用 Pwngdb 進行逐步演示和說明。

Pwngdb 是由 Angelboy 基於 GDB(GNU Debugger)開發的一款專為 Pwn 設計的 Debug 工具,基本操作與 GDB 相同,但進行 Pwn 分析時更加便利。

呼叫 main() -Stack 用於函數呼叫

在這部分,我們將從 _start() 開始一路追蹤到 main(),並以呼叫 main() 的過程說明 Stack 如何在函數呼叫中發揮作用。

註:__libc_start_main() 的內部實作中,實際上是透過 __libc_start_call_main() 來呼叫 main(),因此,接下來的範例會看到 __libc_start_call_main() 呼叫 main() 而不是直接由 __libc_start_main() 呼叫。

以下是具體操作步驟:
image

  1. 先使用 b *_start(Break)命令,在程式進入點設置斷點,然後使用 r(Run) 執行程式,此時,程式會停在 _start() 的斷點處。接著使用 ni(Next Instruction)逐步執行程式,直到抵達 _start+27
    image

    補充:如果想跳過步驟,也可以直接透過 b *_start+27 命令將斷點設在 _start+27 並執行。

  2. _start+27 處,可以看到程式呼叫 __libc_start_main(),我們先繼續往下執行,使用 si(Step Into)命令進入到被呼叫的函數。
  3. 接下來再使用 b *__libc_start_call_main+120 下斷點,接著使用 c(Continue)繼續執行,直到遇到斷點。
    image

補充 1:如果在執行 r 命令之前嘗試對 __libc_start_call_main+120 設置斷點,會發現 Pwngdb 報錯,提示沒有 Symbol Table。這是因為 libc 是動態載入的。在程式啟動前,動態鏈接的函式庫還沒有載入到記憶體,因此 Debugger 無法解析相關 Symbol,這就是為什麼無法在一開始就對該函數設置斷點的原因。

補充 2:依據環境與 glibc 版本不同,讀者的函數位址的偏移量(_start+27 的 27 即為偏移量)可能會跟上面例子有所差異,這時需要讀者自己使用 ni 一步一步找到目標位址。

當程式停在最後的斷點時,可以觀察到__libc_start_call_main() 使用 call 指令來呼叫 rax 暫存器中的位址,我們可以觀察 Pwngdb 上方 REGISTERS 欄位中的 rax 值。
image
發現 rax 儲存一個位址, ◂— push rbp 代表著是這個位址儲存的資料,也就是 main() 的第一條指令。即代表它使用 call 指令來呼叫 main() 這個函數。
接下來,使用 si 進入 main(),我們會觀察到兩個暫存器的變化,這些暫存器將以紅色標示。
image
以下是這兩個暫存器的說明:

  • RIP:我們知道 CPU 在執行的過程中是一條一條往下執行的,而 CPU 如何知道下一條指令的位置就是透過 RIP,它負責儲存下一條指令的位址。在 DISASM 欄位中 ► 指向的綠色指令代表著下一條要執行的指令,可以看到它的位址與 RIP 存的位址相同。
    image
  • RSP:前面有提到 Stack 會在記憶體當中佔一個區塊,並且是往下生長。
    image
    然而作業系統究竟是如何實現這個資料結構的呢?很簡單,我們只要把 Stack 的最頂端(最上層的洋芋片)存起來就好,而 RSP 就是用於存它的。整個運作流程如下圖所示:
    image
    這時 CPU 就能透過 RSP 去做存取,回到程式,我們可以注意到在還沒呼叫前 Stack 是長這樣的:
    image
    使用 call 指令呼叫函數後變成這樣:
    image
    可以發現呼叫前的下一條指令位址被 Push 進去了
    image

    補充-如何讀 Pwngdb 的 Stack 欄位:
    為了方便閱讀,Pwngdb 的 Stack 部分由下往上是高地址到低地址(將倒置的洋芋片罐轉回來)。
    此外 0x7fffffffd748 —▸ 0x7ffff7deac8a ◂— mov edi, eax 代表的是在 0x7fffffffd748 儲存了 0x7ffff7deac8a 這個位址,而這個位址上的值為 ◂— mov edi, eax
    用示意圖表示如下:
    image
    image

由此可見,call 這個指令做了兩件事,一是將 Return Address 推入 Stack,二是跳轉進 main() 內執行函數內容。

執行 main() - Stack 用於儲存函數資料

在上一篇文章中,我們已經介紹了全域變數儲存在哪些 Sections,但還沒提到區域變數的存放位置。區域變數是由 Stack 來管理的。由於一個程式可能會使用到許多函數,作業系統會為每個函數分配一塊專屬的 Stack 空間,稱為 Stack Frame。為了實現這個功能,編譯器會在每個函數的開頭加上一些指令,這些指令稱為 Function Prologue(函數前言)。

接續上面的過程,我們來看看 main() 函數開頭的三行指令,這三行就是 Function Prologue。
image

前面已經介紹過 RSP 是指向 Stack 頂端的暫存器,那麼 RBP 又是什麼呢?由於每個函數都有自己的 Stack Frame,為了讓作業系統知道 Stack Frame 的起始位置,需要一個暫存器來指向 Stack Frame 的底部(如洋芋片罐的底部)。這個暫存器就是 RBP,它負責儲存 Stack Frame 底部的位址。

接下來我們逐一說明這三條指令:

  1. push rbp
    image
    可以看到 RBP 的值被 Push 進了 Stack。為什麼要有這條指令?當一個函數呼叫另一個函數時(例如 main() 去呼叫 printf()),兩者都有各自的 Stack Frame。因此,在被呼叫函數執行完畢後,呼叫者需要恢復其原有的 RBP。這就是為什麼在每個函數執行前,需要先儲存呼叫者的 RBP,這個值通常被稱為 Saved RBP。
  2. mov rbp, rsp
    image
    這條指令的意思是將 RSP 的值複製給 RBP,因此我們可以注意到現在 RBPRSP 相同,指向 Stack 中的同一個地方。現在 RBP 指向的內容即為被呼叫者的 Stack Frame 底部。
  3. sub rsp, 0x10
    image
    顧名思義這條指令就是將 RSP 減掉 0x10,可以注意到一開始 RSP 的值為 0x7fffffffd740 現在變為 0x7fffffffd730。而現在 RSPRBP 之間的空間就是 Stack Frame。

到目前為止,整個 Stack 的示意圖如下:
image

結束 main() 執行

繼續往下執行程式,我們會看到以下這兩條指令:
image
這兩行是 Function Epilogue(函數後語),負責將程式控制權交還給呼叫者。

  1. leave
    leave 這個指令其實是由 mov rbp, rsppop rbp 這兩個指令所組成。它首先先將 RSP 的值複製給 RBP
    image
    這時候 RSP 指著的 Stack 頂部的值為 Saved RBP,此時再透過 pop rbp 將 Saved RBP 的值彈出並存入 RBPRBP 就能恢復被呼叫前的狀態。
  2. ret
    ret 指令等價於 pop rip,由於經歷過剛剛的 leave,因此 Stack 變成這樣:
    image
    當將 Stack 頂部的值彈出後,RIP 的值會變成 Return Address,CPU 會依據 RIP 指向的位址繼續執行,回到呼叫函數的下一條指令。

以上就是整個函數呼叫的流程,下一篇文章將會說明攻擊者會如何利用 Stack。


上一篇
[Day3] 基礎知識 - 執行檔如何被執行
下一篇
[Day5] 漏洞介紹 - Stack Buffer Overflow
系列文
Pwn2Noooo! 執行即 Crash 的 PWNer 養成遊戲13
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言